import NextAuth, { NextAuthOptions, Session, User, Account } from 'next-auth' import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' import { SAMLProvider } from './saml/provider' import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' import { verifySmsToken, verifyEmailToken } from '@/lib/users/auth/passwordUtil' import { SessionRepository } from '@/lib/users/session/repository' import { getUserRoles } from '@/lib/users/service' // 인증 방식 타입 정의 type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' // ✅ 모듈 보강 선언 - roles 배열 추가 declare module "next-auth" { interface Session { user: { id: string name?: string | null email?: string | null image?: string | null companyId?: number | null techCompanyId?: number | null ownerCompanyId?: number | null domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod sessionExpiredAt?: number | null dbSessionId?: string | null roles?: string[] // ✅ roles 배열 추가 } } interface User { id: string imageUrl?: string | null companyId?: number | null techCompanyId?: number | null ownerCompanyId?: number | null domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod dbSessionId?: string | null roles?: string[] // ✅ roles 배열 추가 } } declare module "next-auth/jwt" { interface JWT { id?: string imageUrl?: string | null companyId?: number | null techCompanyId?: number | null ownerCompanyId?: number | null domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod sessionExpiredAt?: number | null dbSessionId?: string | null roles?: string[] // ✅ roles 배열 추가 } } // 타입 변환 헬퍼 함수들 function ensureString(value: string | number): string { return String(value) } function ensureNumber(value: string | number): number { return typeof value === 'string' ? parseInt(value, 10) : value } // 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null lastFetch: number ttl: number } = { data: null, lastFetch: 0, ttl: 5 * 60 * 1000 } async function getCachedSecuritySettings() { const now = Date.now() if (!securitySettingsCache.data || (now - securitySettingsCache.lastFetch) > securitySettingsCache.ttl) { try { securitySettingsCache.data = await getSecuritySettings() securitySettingsCache.lastFetch = now } catch (error) { console.error('Failed to fetch security settings:', error) securitySettingsCache.data = { sessionTimeoutMinutes: 480 } } } return securitySettingsCache.data } // 클라이언트 IP 추출 헬퍼 function getClientIP(req: any): string { const forwarded = req.headers['x-forwarded-for'] const realIP = req.headers['x-real-ip'] if (forwarded) { return forwarded.split(',')[0].trim() } if (realIP) { return realIP } return req.ip || req.connection?.remoteAddress || '127.0.0.1' } export const authOptions: NextAuthOptions = { providers: [ // ✅ OTP 로그인 - roles 정보 추가 CredentialsProvider({ id: 'credentials-otp', name: 'OTP', credentials: { email: { label: 'Email', type: 'text' }, code: { label: 'OTP code', type: 'text' }, }, async authorize(credentials, req) { const { email, code } = credentials ?? {} const user = await verifyOtpTemp(email ?? '') if (!user) { return null } // ✅ 사용자 roles 정보 조회 const userRoles = await getUserRoles(user.id) const securitySettings = await getCachedSecuritySettings() const reAuthTime = Date.now() return { id: ensureString(user.id), email: user.email, imageUrl: user.imageUrl ?? null, name: user.name, companyId: user.companyId, techCompanyId: user.techCompanyId as number | undefined, domain: user.domain, reAuthTime, authMethod: 'otp' as AuthMethod, roles: userRoles, // ✅ roles 배열 추가 } }, }), // ✅ MFA 완료 후 최종 인증 - roles 정보 추가 (SMS/Email OTP 지원) CredentialsProvider({ id: 'credentials-mfa', name: 'MFA Verification', credentials: { userId: { label: 'User ID', type: 'text' }, smsToken: { label: 'SMS Token', type: 'text' }, // SMS 또는 Email OTP 토큰 tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, mfaType: { label: 'MFA Type', type: 'text' }, // 'sms' 또는 'email' }, async authorize(credentials, req) { if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { console.error('MFA credentials missing') return null } const numericUserId = ensureNumber(credentials.userId) const user = await getUserById(numericUserId) if (!user) { console.error('User not found after MFA verification') return null } try { // DB에서 임시 인증 정보 확인 const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) if (!tempAuth || ensureNumber(tempAuth.userId) !== user.id) { console.error('Temp auth expired or not found') return null } // MFA 타입에 따라 SMS 또는 Email OTP 검증 const mfaType = credentials.mfaType || 'sms'; // 기본값은 SMS let verificationResult; if (mfaType === 'email') { verificationResult = await verifyEmailToken(user.id, credentials.smsToken) console.log(`Email OTP verification for user ${user.email}:`, verificationResult.success) } else { verificationResult = await verifySmsToken(user.id, credentials.smsToken) console.log(`SMS OTP verification for user ${user.email}:`, verificationResult.success) } if (!verificationResult || !verificationResult.success) { console.error(`${mfaType.toUpperCase()} token verification failed`) return null } // 임시 인증 정보를 사용됨으로 표시 await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey) // ✅ 사용자 roles 정보 조회 const userRoles = await getUserRoles(user.id) // 보안 설정 및 세션 정보 설정 const securitySettings = await getCachedSecuritySettings() const reAuthTime = Date.now() const sessionExpiredAt = new Date(reAuthTime + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) // DB에 로그인 세션 생성 const ipAddress = getClientIP(req) const userAgent = req.headers?.['user-agent'] const dbSession = await SessionRepository.createLoginSession({ userId: user.id, ipAddress, userAgent, authMethod: tempAuth.authMethod, sessionExpiredAt, }) console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) return { id: ensureString(user.id), email: user.email, imageUrl: user.imageUrl ?? null, name: user.name, companyId: user.companyId, techCompanyId: user.techCompanyId as number | undefined, domain: user.domain, reAuthTime, authMethod: tempAuth.authMethod as AuthMethod, dbSessionId: dbSession.id, roles: userRoles, // ✅ roles 배열 추가 epId: user.epId, // Knox 계정인 경우, epId 추가 (Knox API 사용하는 경우 필요) } } catch (error) { console.error('MFA authorization error:', error) return null } }, }), // 1차 인증용 프로바이더 (기존 유지) CredentialsProvider({ id: 'credentials-first-auth', name: 'First Factor Authentication', credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, provider: { label: "Provider", type: "text" }, }, async authorize(credentials, req) { return null } }), // SAML Provider (기존 유지) SAMLProvider({ id: "credentials-saml", name: "SAML SSO", idp: { sso_login_url: process.env.SAML_IDP_SSO_URL!, sso_logout_url: process.env.SAML_IDP_SLO_URL || '', certificates: [process.env.SAML_IDP_CERT!] }, sp: { entity_id: process.env.SAML_SP_ENTITY_ID!, private_key: process.env.SAML_SP_PRIVATE_KEY || '', certificate: process.env.SAML_SP_CERT || '', assert_endpoint: process.env.SAML_SP_CALLBACK_URL || `${process.env.NEXTAUTH_URL}/api/saml/callback` } }) ], session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60, }, callbacks: { // ✅ JWT callback에 roles 정보 추가 // JWT callback 수정 async jwt({ token, user, account, trigger, session }) { const securitySettings = await getCachedSecuritySettings() const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() token.id = user.id token.email = user.email token.name = user.name token.companyId = user.companyId token.techCompanyId = user.techCompanyId token.domain = user.domain token.imageUrl = user.imageUrl token.reAuthTime = reAuthTime token.authMethod = user.authMethod token.sessionExpiredAt = reAuthTime + sessionTimeoutMs token.dbSessionId = user.dbSessionId token.roles = user.roles token.ownerCompanyId = user.ownerCompanyId } // ✅ 기존 토큰이 있고 로그인이 아닌 경우, DB에서 최신 사용자 정보 조회 if (token.id && !user && trigger !== "update") { try { const latestUser = await getUserById(parseInt(token.id)) if (latestUser) { // 도메인이 변경되었다면 토큰 업데이트 if (token.domain !== latestUser.domain) { console.log(`Domain changed for user ${token.email}: ${token.domain} -> ${latestUser.domain}`) token.domain = latestUser.domain } // 기타 정보도 최신 상태로 업데이트 token.name = latestUser.name token.companyId = latestUser.companyId token.techCompanyId = latestUser.techCompanyId token.ownerCompanyId = latestUser.ownerCompanyId // roles 정보도 최신으로 업데이트 const userRoles = await getUserRoles(parseInt(token.id)) token.roles = userRoles } } catch (error) { console.error('Failed to fetch latest user info in JWT callback:', error) } } // SAML 인증 시 DB 세션 생성 및 roles 조회 if (account && account.provider === 'credentials-saml' && token.id) { const reAuthTime = Date.now() const sessionExpiredAt = new Date(reAuthTime + sessionTimeoutMs) try { const numericUserId = ensureNumber(token.id) // SAML 로그인 시에도 최신 사용자 정보 조회 const latestUser = await getUserById(numericUserId) if (latestUser) { token.domain = latestUser.domain token.name = latestUser.name token.companyId = latestUser.companyId token.techCompanyId = latestUser.techCompanyId token.ownerCompanyId = latestUser.ownerCompanyId token.roles = await getUserRoles(numericUserId) } const dbSession = await SessionRepository.createLoginSession({ userId: numericUserId, ipAddress: '0.0.0.0', authMethod: 'saml', sessionExpiredAt, }) token.authMethod = 'saml' token.reAuthTime = reAuthTime token.sessionExpiredAt = reAuthTime + sessionTimeoutMs token.dbSessionId = dbSession.id } catch (error) { console.error('Failed to create SAML session:', error) } } // 세션 업데이트 시 if (trigger === "update" && session) { if (session.reAuthTime !== undefined) { token.reAuthTime = session.reAuthTime token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs // DB 세션 업데이트 if (token.dbSessionId) { await SessionRepository.updateLoginSession(token.dbSessionId, { lastActivityAt: new Date(), sessionExpiredAt: new Date(session.reAuthTime + sessionTimeoutMs) }) } } if (session.user) { if (session.user.name !== undefined) token.name = session.user.name if (session.user.email !== undefined) token.email = session.user.email if (session.user.image !== undefined) token.imageUrl = session.user.image // ✅ 세션 업데이트 시 도메인 정보도 갱신 가능 if (session.user.domain !== undefined) token.domain = session.user.domain } } return token } , // ✅ Session callback에 roles 정보 추가 async session({ session, token }: { session: Session; token: JWT }) { // 세션 만료 체크 if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) { console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`) // DB 세션 만료 처리 if (token.dbSessionId) { await SessionRepository.logoutSession(token.dbSessionId) } return { expires: new Date(0).toISOString(), user: null as any } } if (token) { session.user = { id: token.id as string, email: token.email as string, name: token.name as string, domain: token.domain as string, companyId: token.companyId as number, techCompanyId: token.techCompanyId as number, ownerCompanyId: token.ownerCompanyId as number, image: token.imageUrl ?? null, reAuthTime: token.reAuthTime as number | null, authMethod: token.authMethod as AuthMethod, sessionExpiredAt: token.sessionExpiredAt as number | null, dbSessionId: token.dbSessionId as string | null, roles: token.roles as string[] || [], // ✅ roles 정보 추가 } } return session }, async redirect({ url, baseUrl }) { if (url.startsWith("/")) { return `${baseUrl}${url}`; } else if (new URL(url).origin === baseUrl) { return url; } return baseUrl; }, }, pages: { signIn: '/auth/login', error: '/auth/error', }, events: { async signIn({ user, account, profile }) { const securitySettings = await getCachedSecuritySettings() console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`); // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 if (account?.provider !== 'credentials-mfa' && user.id) { try { const numericUserId = ensureNumber(user.id) // 기존 활성 세션 확인 const existingSession = await SessionRepository.getActiveSessionByUserId(numericUserId) if (!existingSession) { const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) await SessionRepository.createLoginSession({ userId: numericUserId, ipAddress: '0.0.0.0', authMethod: user.authMethod || 'unknown', sessionExpiredAt, }) } } catch (error) { console.error('Failed to create session in signIn event:', error) } } }, async signOut({ session, token }) { console.log(`User ${session?.user?.email || token?.email} signed out`); // DB에서 세션 로그아웃 처리 const userId = session?.user?.id || token?.id const dbSessionId = session?.user?.dbSessionId || token?.dbSessionId if (dbSessionId) { await SessionRepository.logoutSession(dbSessionId) } else if (userId) { // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 const numericUserId = ensureNumber(userId) await SessionRepository.logoutAllUserSessions(numericUserId) } } } } const handler = NextAuth(authOptions) export { handler as GET, handler as POST }